iT邦幫忙

2025 iThome 鐵人賽

DAY 24
0
Vue.js

需求至上的 Vue 魔法之旅系列 第 24

Day 17 : 多語系施法術:Vue i18n 靜態 + 後端動態字典完整實戰

  • 分享至 

  • xImage
  •  

前言

相信到這個地步,我們身為厲害的魔法師
一定會需要與各國的魔法師交流~這時候就需要多國語言的處理~!!

今天我們把語言切換這門魔法真正施展起來!
在小型專案裡,直接把文案寫死在前端就夠用了;但當飲料口味一路從紅茶、抹茶、巧克力長到「一整張菜單」、還要支援多國旅人時,把文案與資料分離就成了必修咒語。於是本章我們同時準備兩把魔杖:

  1. 前端靜態 i18n(UI 文案):像「點餐之塔」、「送出」、「統計結果」這些固定字串,放在 src/locales/*.json,版本可控、好維護。
  2. 後端動態字典(資料翻譯):像「紅茶 / 綠茶 / 去冰 / 正常甜」這類會隨菜單更動的字,改由後端提供 i18n.json 與 API 管理,Admin 可線上編輯,前端即時套用,不用重新發版。

此外,我們會讓使用者的語系偏好跟著登入狀態同步回後端,下次進來自動還原。整套完成後,你會擁有:可分享、可維護、可擴充的多語系體驗,既符合工程實務,也符合魔法學院對「優雅」的標準

今日任務清單(一次看懂要做什麼)

類別 檔案/動作 目的
安裝 npm i vue-i18n 啟用前端 i18n 能力
新增(前端) src/locales/zh-TW.jsonen-US.jsonja-JP.json 靜態 UI 文案(標題、按鈕、欄位、提示)
新增(前端) src/stores/i18nStore.js 管理後端字典偏好語系(Pinia)
新增(前端) src/pages/AdminI18nPage.vue Admin 編輯動態字典
修改(前端) src/main.js 掛載 i18n、載入靜態語系
修改(前端) src/App.vue 語系切換器、標題 $t、同步偏好
修改(前端) src/router/index.js /admin/i18n 路由 + 守衛 + afterEach 設定標題
修改(前端) src/pages/OrderPage.vue 選項使用「字典翻譯 label / value 原鍵」
修改(前端) src/components/OptionGroup.vue 支援 { value, label }
修改(前端) src/components/OrderForm.vueOrderList.vueOrderStats.vueOrderDetailPage.vueLoginPage.vue 文案改 $t(本文節錄關鍵)
新增(後端) backend/i18n.json 動態字典檔(可被 API 讀寫)
修改(後端) backend/server.js 新增 /api/i18n-config/api/users/me/api/users/me/locale

使用者需求(User Story)

角色 需求 目的 路徑/功能
外國魔法師 介面要能切換英文/日文 看得懂 UI 前端 i18n(靜態 JSON)
祕書/管理者(roni) 想線上編輯「飲料/甜度/冰量」翻譯 不發版也能修改資料翻譯 /admin/i18n + 後端 /api/i18n-config
一般使用者 切換語系後下次還是同語系 保留偏好語言 /api/users/me/locale
訂單使用者/客服 列表/詳情/統計畫面顯示翻譯 觀感一致 以字典翻譯顯示,value 保留原始鍵

必備套件知識

  • vue-i18n:Vue 官方 i18n 套件。

    • $t('key'):取字串
    • locale.value = 'en-US':切換語系
  • Pinia:集中管理 i18n 字典狀態與 API 沟通(i18nStore)。

  • 兩種翻譯來源

    1. 靜態:UI 固定文案(前端 JSON,版本控管)
    2. 動態:資料型選項(飲料/甜度/冰量)從後端字典取得,可由 Admin 線上更新

設計準則:UI 文案(像「送出」、「登入」)放前端;商品字典(紅茶/綠茶…)放後端,方便營運修改。


時序圖:切換語系 & 儲存偏好

我們來看一下今天的運作流程

https://ithelp.ithome.com.tw/upload/images/20251008/20121052qEfwhJ17Bn.png


流程圖:Admin 編輯字典

https://ithelp.ithome.com.tw/upload/images/20251008/201210525VJdlZB9An.png


程式碼實作

1) 安裝

cd day17/frontend
npm i vue-i18n

2) 掛載 i18n:src/main.js

import { createApp } from 'vue'
import { createPinia } from 'pinia'
import './style.css'
import App from './App.vue'
import { router } from './router'
import { createI18n } from 'vue-i18n'
import zhTW from './locales/zh-TW.json'
import enUS from './locales/en-US.json'
import jaJP from './locales/ja-JP.json'

const app = createApp(App)
app.use(createPinia())
app.use(router)

const i18n = createI18n({
  legacy: false,
  locale: 'zh-TW',
  fallbackLocale: 'en-US',
  messages: { 'zh-TW': zhTW, 'en-US': enUS, 'ja-JP': jaJP }
})

app.use(i18n)
app.mount('#app')

3) 前端靜態翻譯檔(UI 文案)

這三支是前端介面固定文案翻譯檔,放 src/locales

src/locales/zh-TW.json

{
  "app": { "title": "飲料點單系統 (Router 版)", "header": "飲料點單系統 (Router 版)" },
  "nav": { "order": "點餐之塔", "summary": "結算之室" },
  "pages": {
    "login": "登入",
    "loginHint": "說明:若此使用者不存在將自動建立(密碼固定 123456)。使用者 roni 具備 admin 權限。",
    "summary": "結算之室",
    "orderDetail": "訂單詳情",
    "currentOrders": "目前已送出的訂單",
    "bulkImport": "祕書匯入訂單(貼上 JSON 陣列)",
    "stats": "統計結果"
  },
  "fields": {
    "name": "姓名",
    "note": "備註",
    "drink": "飲料",
    "sweetness": "甜度",
    "ice": "冰量",
    "count": "數量",
    "totalCups": "總杯數",
    "createdAt": "建立時間",
    "updatedAt": "更新時間",
    "username": "使用者名稱",
    "password": "密碼"
  },
  "steps": {
    "pickDrink": "步驟 1:選擇飲料",
    "pickSweetness": "步驟 2:選擇甜度",
    "pickIce": "步驟 3:選擇冰量"
  },
  "actions": {
    "reload": "重新載入",
    "applyToBackend": "套用到後端",
    "reloadFromBackend": "重新載入(後端)",
    "backToOrder": "回到點餐",
    "submit": "送出",
    "login": "登入",
    "detail": "詳情",
    "edit": "編輯",
    "collapse": "收合",
    "delete": "刪除",
    "save": "儲存",
    "cancel": "取消"
  },
  "placeholders": {
    "name": "請輸入你的名字",
    "note": "例如:三點拿、少冰",
    "username": "例如 corgi / roni",
    "password": "預設 123456"
  },
  "common": { "loading": "載入中...", "required": "必填", "optional": "選填" },
  "validations": {
    "pickSweetness": "請選擇甜度",
    "noSugarForMatcha": "抹茶拿鐵不可去糖",
    "pickIce": "請選擇冰量",
    "chocolateHotOnly": "巧克力僅能熱飲",
    "nameRequired": "姓名必填",
    "nameMin": "至少 2 個字",
    "nameMax": "最多 20 個字",
    "noteMax": "備註最多 50 個字",
    "noteBlacklist": "備註含禁用詞",
    "pickDrink": "請選擇飲料",
    "menuRuleMismatch": "選項與菜單規則不符",
    "dailyLimit": "同一位使用者今日最多 3 杯",
    "businessHours": "非營業時間(08:00–22:00)不可送單"
  }
}

src/locales/en-US.json

{
  "app": { "title": "Drink Ordering System (Router)", "header": "Drink Ordering System (Router)" },
  "nav": { "order": "Order Tower", "summary": "Settlement Room" },
  "pages": {
    "login": "Login",
    "loginHint": "If user does not exist, it will be created (password 123456). User roni has admin role.",
    "summary": "Settlement Room",
    "orderDetail": "Order Detail",
    "currentOrders": "Submitted Orders",
    "bulkImport": "Secretary Import (paste JSON array)",
    "stats": "Statistics"
  },
  "fields": {
    "name": "Name",
    "note": "Note",
    "drink": "Drink",
    "sweetness": "Sweetness",
    "ice": "Ice",
    "count": "Count",
    "totalCups": "Total Cups",
    "createdAt": "Created At",
    "updatedAt": "Updated At",
    "username": "Username",
    "password": "Password"
  },
  "steps": {
    "pickDrink": "Step 1: Choose Drink",
    "pickSweetness": "Step 2: Choose Sweetness",
    "pickIce": "Step 3: Choose Ice"
  },
  "actions": {
    "reload": "Reload",
    "applyToBackend": "Apply to Backend",
    "reloadFromBackend": "Reload (Backend)",
    "backToOrder": "Back to Order",
    "submit": "Submit",
    "login": "Login",
    "detail": "Detail",
    "edit": "Edit",
    "collapse": "Collapse",
    "delete": "Delete",
    "save": "Save",
    "cancel": "Cancel"
  },
  "placeholders": {
    "name": "Enter your name",
    "note": "e.g. pick at 3pm, less ice",
    "username": "e.g. corgi / roni",
    "password": "default 123456"
  },
  "common": { "loading": "Loading...", "required": "Required", "optional": "Optional" },
  "validations": {
    "pickSweetness": "Please choose sweetness",
    "noSugarForMatcha": "Matcha Latte cannot be sugar-free",
    "pickIce": "Please choose ice",
    "chocolateHotOnly": "Chocolate is hot only",
    "nameRequired": "Name is required",
    "nameMin": "At least 2 characters",
    "nameMax": "At most 20 characters",
    "noteMax": "Note at most 50 characters",
    "noteBlacklist": "Note contains forbidden terms",
    "pickDrink": "Please choose drink",
    "menuRuleMismatch": "Selection violates menu rules",
    "dailyLimit": "Max 3 cups per person per day",
    "businessHours": "Ordering not allowed outside 08:00–22:00"
  }
}

src/locales/ja-JP.json

{
  "app": { "title": "ドリンク注文システム(ルーター版)", "header": "ドリンク注文システム(ルーター版)" },
  "nav": { "order": "注文の塔", "summary": "精算の間" },
  "pages": {
    "login": "ログイン",
    "loginHint": "ユーザーが存在しない場合は作成されます(パスワード 123456)。roni は管理者です。",
    "summary": "精算の間",
    "orderDetail": "注文詳細",
    "currentOrders": "送信済みの注文",
    "bulkImport": "秘書インポート(JSON 配列を貼り付け)",
    "stats": "統計"
  },
  "fields": {
    "name": "名前",
    "note": "備考",
    "drink": "ドリンク",
    "sweetness": "甘さ",
    "ice": "氷",
    "count": "数量",
    "totalCups": "合計杯数",
    "createdAt": "作成日時",
    "updatedAt": "更新日時",
    "username": "ユーザー名",
    "password": "パスワード"
  },
  "steps": {
    "pickDrink": "ステップ1:ドリンクを選ぶ",
    "pickSweetness": "ステップ2:甘さを選ぶ",
    "pickIce": "ステップ3:氷を選ぶ"
  },
  "actions": {
    "reload": "再読み込み",
    "applyToBackend": "バックエンドに適用",
    "reloadFromBackend": "(バックエンド)再読み込み",
    "backToOrder": "注文に戻る",
    "submit": "送信",
    "login": "ログイン",
    "detail": "詳細",
    "edit": "編集",
    "collapse": "折りたたむ",
    "delete": "削除",
    "save": "保存",
    "cancel": "キャンセル"
  },
  "placeholders": {
    "name": "お名前を入力してください",
    "note": "例:15時受け取り、氷少なめ",
    "username": "例:corgi / roni",
    "password": "デフォルト 123456"
  },
  "common": { "loading": "読み込み中...", "required": "必須", "optional": "任意" },
  "validations": {
    "pickSweetness": "甘さを選択してください",
    "noSugarForMatcha": "抹茶ラテは無糖不可",
    "pickIce": "氷を選択してください",
    "chocolateHotOnly": "チョコレートはホットのみ",
    "nameRequired": "名前は必須です",
    "nameMin": "2文字以上",
    "nameMax": "20文字以内",
    "noteMax": "備考は最大50文字",
    "noteBlacklist": "備考に禁止語があります",
    "pickDrink": "ドリンクを選択してください",
    "menuRuleMismatch": "選択がメニュールールに違反しています",
    "dailyLimit": "1日一人3杯まで",
    "businessHours": "営業時間(08:00–22:00)外は注文不可"
  }
}

4) i18n Store:src/stores/i18nStore.js

(負責動態字典 + 偏好語系)

import { defineStore } from 'pinia'
import { http } from '../services/http'

export const useI18nStore = defineStore('i18n', {
  state: () => ({
    languages: ['zh-TW','en-US','ja-JP'],
    dict: { drinks: {}, sweetness: {}, ice: {} },
    preferredLocale: 'zh-TW',
    loading: false,
    error: ''
  }),
  actions: {
    async loadServerConfig() {
      this.loading = true
      this.error = ''
      try {
        const { data } = await http.get('/api/i18n-config')
        this.languages = data.languages || this.languages
        this.dict = { drinks: data.drinks || {}, sweetness: data.sweetness || {}, ice: data.ice || {} }
      } catch (e) {
        this.error = '載入 i18n 設定失敗'
      } finally {
        this.loading = false
      }
    },
    async saveServerConfig(payload, token) {
      this.loading = true
      this.error = ''
      try {
        const { data } = await http.put('/api/i18n-config', payload, { headers: { Authorization: `Bearer ${token}` } })
        this.languages = data.languages
        this.dict = { drinks: data.drinks, sweetness: data.sweetness, ice: data.ice }
      } catch (e) {
        this.error = e.response?.data?.error || '更新 i18n 失敗'
      } finally {
        this.loading = false
      }
    },
    translate(category, key, locale) {
      const table = this.dict[category] || {}
      const row = table[key]
      if (!row) return key
      return row[locale] || key
    },
    async loadMe(token) {
      try {
        const { data } = await http.get('/api/users/me', { headers: { Authorization: `Bearer ${token}` } })
        this.preferredLocale = data.preferredLocale || this.preferredLocale
      } catch {}
    },
    async updatePreferredLocale(locale, token) {
      try {
        await http.put('/api/users/me/locale', { locale }, { headers: { Authorization: `Bearer ${token}` } })
        this.preferredLocale = locale
      } catch {}
    }
  }
})

5) Admin 字典編輯頁:src/pages/AdminI18nPage.vue

<script setup>
import { onMounted, reactive } from 'vue'
import { useAuthStore } from '../stores/authStore'
import { useI18nStore } from '../stores/i18nStore'
import { useI18n } from 'vue-i18n'

const auth = useAuthStore()
const i18nStore = useI18nStore()
const { t, locale } = useI18n()

const form = reactive({ languages: [], drinks: {}, sweetness: {}, ice: {} })

onMounted(async () => {
  await i18nStore.loadServerConfig()
  form.languages = [...i18nStore.languages]
  form.drinks = JSON.parse(JSON.stringify(i18nStore.dict.drinks))
  form.sweetness = JSON.parse(JSON.stringify(i18nStore.dict.sweetness))
  form.ice = JSON.parse(JSON.stringify(i18nStore.dict.ice))
})

function addKey(cat) {
  const key = prompt('新增鍵(例如 綠茶)')
  if (!key) return
  if (!form[cat][key]) form[cat][key] = {}
  for (const lang of form.languages) {
    if (!form[cat][key][lang]) form[cat][key][lang] = ''
  }
}
async function save() {
  await i18nStore.saveServerConfig({ languages: form.languages, drinks: form.drinks, sweetness: form.sweetness, ice: form.ice }, auth.token)
}

function setLocale(lang) {
  locale.value = lang
}
</script>

<template>
  <section class="page">
    <h2>{{ t('pages.stats') }} Admin i18n</h2>
    <div v-if="i18nStore.error" class="error-message">{{ i18nStore.error }}</div>

    <div class="block">
      <label>UI 語系:
        <select class="btn" @change="setLocale($event.target.value)">
          <option value="zh-TW">中文</option>
          <option value="en-US">English</option>
          <option value="ja-JP">日本語</option>
        </select>
      </label>
    </div>

    <div class="block">
      <h3>飲料字典</h3>
      <button class="btn btn-sm" @click="addKey('drinks')">新增鍵</button>
      <div v-for="(row, key) in form.drinks" :key="key" class="block">
        <div><b>{{ key }}</b></div>
        <div style="display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-top:6px">
          <label v-for="lang in form.languages" :key="lang">{{ lang }}:<input v-model="form.drinks[key][lang]" /></label>
        </div>
      </div>
    </div>

    <div class="block">
      <h3>甜度字典</h3>
      <button class="btn btn-sm" @click="addKey('sweetness')">新增鍵</button>
      <div v-for="(row, key) in form.sweetness" :key="key" class="block">
        <div><b>{{ key }}</b></div>
        <div style="display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-top:6px">
          <label v-for="lang in form.languages" :key="lang">{{ lang }}:<input v-model="form.sweetness[key][lang]" /></label>
        </div>
      </div>
    </div>

    <div class="block">
      <h3>冰量字典</h3>
      <button class="btn btn-sm" @click="addKey('ice')">新增鍵</button>
      <div v-for="(row, key) in form.ice" :key="key" class="block">
        <div><b>{{ key }}</b></div>
        <div style="display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-top:6px">
          <label v-for="lang in form.languages" :key="lang">{{ lang }}:<input v-model="form.ice[key][lang]" /></label>
        </div>
      </div>
    </div>

    <div class="actions" style="margin-top:8px">
      <button class="btn primary" @click="save">{{ t('actions.save') }}</button>
    </div>
  </section>
</template>

6) App:語系切換 + 標題 $t(節錄)

語系切換時更新 document.title,若已登入則同步偏好到後端。

<!-- src/App.vue -->
<script setup>
import { useAuthStore } from './stores/authStore'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useI18nStore } from './stores/i18nStore'

const auth = useAuthStore()
const router = useRouter()
const { t, locale } = useI18n()
const i18nStore = useI18nStore()

function logout() { auth.clear(); router.push('/login') }
function switchLocale(lang) {
  locale.value = lang
  document.title = t('app.title')
  if (auth.isAuthenticated) i18nStore.updatePreferredLocale(lang, auth.token)
}
</script>

<template>
  <main class="page">
    <h1>{{ t('app.header') }}</h1>
    <nav style="display:flex; gap:8px; margin:12px 0;">
      <router-link to="/order" class="btn">{{ t('nav.order') }}</router-link>
      <router-link to="/summary" class="btn">{{ t('nav.summary') }}</router-link>
      <span style="flex:1"></span>
      <select class="btn" @change="switchLocale($event.target.value)">
        <option value="zh-TW">中文</option>
        <option value="en-US">English</option>
        <option value="ja-JP">日本語</option>
      </select>
      <button v-if="auth.isAuthenticated" class="btn" @click="logout">登出</button>
    </nav>
    <router-view />
  </main>
</template>

7) Router:加入 /admin/i18n 與守衛 + afterEach 設標題

// src/router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import OrderPage from '../pages/OrderPage.vue'
import SummaryPage from '../pages/SummaryPage.vue'
import OrderDetailPage from '../pages/OrderDetailPage.vue'
import LoginPage from '../pages/LoginPage.vue'
import AdminI18nPage from '../pages/AdminI18nPage.vue'
import { useAuthStore } from '../stores/authStore'
import { useI18n } from 'vue-i18n'

const routes = [
  { path: '/', redirect: '/order' },
  { path: '/login', component: LoginPage },
  { path: '/order', component: OrderPage },
  { path: '/summary', component: SummaryPage, meta: { requiresAdmin: true } },
  { path: '/order/:id', component: OrderDetailPage },
  { path: '/admin/i18n', component: AdminI18nPage, meta: { requiresAdmin: true } },
]

export const router = createRouter({ history: createWebHistory(), routes })

router.beforeEach((to) => {
  const auth = useAuthStore()
  if (to.path !== '/login' && !auth.isAuthenticated) return { path: '/login', query: { redirect: to.fullPath } }
  if (to.meta?.requiresAdmin && !auth.isAdmin) return { path: '/order' }
})

router.afterEach(() => {
  try { const { t } = useI18n(); document.title = t('app.title') } catch {}
})

8) 點單頁:把「值」送後端、把「翻譯」給使用者看

字典轉 label 顯示、但 value 仍是原鍵(不污染資料層)

<!-- src/pages/OrderPage.vue(節錄表單傳入選項的部分) -->
<OrderForm
  :disabled="orderStore.loading"
  :drinks="menuStore.drinks.map(d => ({ value: d, label: i18nStore.translate('drinks', d, $i18n.locale) }))"
  :sweetnessOptions="menuStore.sweetnessOptions.map(s => ({ value: s, label: i18nStore.translate('sweetness', s, $i18n.locale) }))"
  :iceOptions="menuStore.iceOptions.map(i => ({ value: i, label: i18nStore.translate('ice', i, $i18n.locale) }))"
  :menuRules="menuStore.rules"
  @submit="handleSubmit"
/>

9) OptionGroup:支援 { value, label }

<!-- src/components/OptionGroup.vue(關鍵節錄) -->
<label v-for="opt in options" :key="typeof opt === 'string' ? opt : opt.value" style="margin-right:12px">
  <input
    type="radio"
    :checked="modelValue === (typeof opt === 'string' ? opt : opt.value)"
    @change="emit('update:modelValue', (typeof opt === 'string' ? opt : opt.value))"
  />
  {{ typeof opt === 'string' ? opt : opt.label }}
</label>

這樣既相容舊的 ['紅茶','綠茶'],也支援新的 [{value:'紅茶',label:'Black Tea'}]


10) 其他頁面:文案 $t 與顯示翻譯(節錄)

OrderForm.vue(步驟標題改 $t
OrderStats.vue(表格標題 $t
OrderDetailPage.vue(欄位名稱 $t
LoginPage.vue(表單與說明 $t

你前面的改動已經很完整,這裡不重貼全部檔案,只提醒:固定文案都換 $t()


11) 後端動態字典:backend/i18n.json

這是 Admin 可編輯的字典檔,由 /api/i18n-config 讀寫。

{
  "languages": ["zh-TW", "en-US", "ja-JP"],
  "drinks": {
    "紅茶": {"zh-TW": "紅茶", "en-US": "Black Tea", "ja-JP": "こうちゃ"},
    "綠茶": {"zh-TW": "綠茶", "en-US": "Green Tea", "ja-JP": "りょくちゃ"},
    "巧克力": {"zh-TW": "巧克力", "en-US": "Chocolate", "ja-JP": "チョコレート"},
    "抹茶拿鐵": {"zh-TW": "抹茶拿鐵", "en-US": "Matcha Latte", "ja-JP": "抹茶ラテ"}
  },
  "sweetness": {
    "正常甜": {"zh-TW": "正常甜", "en-US": "Regular Sugar", "ja-JP": "普通糖"},
    "少糖": {"zh-TW": "少糖", "en-US": "Less Sugar", "ja-JP": "少なめ"},
    "去糖": {"zh-TW": "去糖", "en-US": "No Sugar", "ja-JP": "無糖"}
  },
  "ice": {
    "正常冰": {"zh-TW": "正常冰", "en-US": "Regular Ice", "ja-JP": "普通氷"},
    "去冰": {"zh-TW": "去冰", "en-US": "Less Ice", "ja-JP": "氷少なめ"},
    "熱飲": {"zh-TW": "熱飲", "en-US": "Hot", "ja-JP": "ホット"}
  }
}

伺服器端點(server.js)前面幫你加過:
GET /api/i18n-configPUT /api/i18n-configGET /api/users/mePUT /api/users/me/locale
你的版本 OK,無需修改。

✅ 驗收清單

  • [ ] UI 固定文字跟著語系切換($t 正常)
  • [ ] 點單頁選項顯示翻譯,送後端的仍是原鍵
  • [ ] Admin 可以新增飲料/甜度/冰量的翻譯並儲存
  • [ ] 已登入切換語系會同步偏好,下次重整仍是該語系

https://ithelp.ithome.com.tw/upload/images/20251008/20121052tKf2TLo4hO.png

https://ithelp.ithome.com.tw/upload/images/20251008/20121052nJLyj0KXZ2.png

這邊我們偷改可可
https://ithelp.ithome.com.tw/upload/images/20251008/20121052V65tyBa8Ww.png

https://ithelp.ithome.com.tw/upload/images/20251008/20121052Em9Ddsvmm7.png

總結(魔法師備忘)

  • UI 文案→ 放前端 locales/*.json,版本控管清楚
  • 資料翻譯→ 放後端字典 i18n.json,Admin 可改、前端即時套用
  • 值/顯示分離→ value 保留原鍵,label 使用翻譯(避免資料污染)
  • 體驗→ 切換語系即時渲染、記住偏好、可分享、可擴充(之後接 CMS / i18n 平台也容易)

🌏 前端靜態語系 vs 後端動態語系 比較表

項目 前端靜態語系檔(locales/*.json) 後端動態語系檔(i18n-config API)
放置位置 src/locales/zh-TW.jsonen-US.json 後端伺服器(如 /api/i18n-config
管理方式 隨專案一起打包,部署時固定 儲存在後端資料庫或 JSON,可線上修改
用途定位 固定 UI 文字(按鈕、標題、欄位名) 動態資料(飲料、甜度、冰量等菜單項目)
更新頻率 少變動,通常版本更新才修改 常變動,例如新增新品、調整翻譯
是否需發版才能更新 ✅ 需要重新部署 ❌ 不需發版,Admin 即可線上編輯
存取方式 Vue i18n 直接載入、以 $t('key') 呼叫 透過 Pinia store 連後端 API 取得
適合場景 介面、系統說明、提示訊息 菜單內容、可動態編輯之資料翻譯
例子 「登入」、「點餐之塔」、「結算之室」 「綠茶 → Green Tea → 緑茶」
語系來源 前端打包時讀取 後端 API 回傳 JSON
適合誰改 前端工程師或翻譯人員 管理者(Admin 面板)
典型使用方式 t('actions.save') i18nStore.translate('drinks', '綠茶', 'en-US')

📌 一句話總結:

「靜態檔是語言魔法書,寫死在卷軸上;
動態檔是魔法字典,讓 Admin 能隨時改動咒語而不需重啟整座塔。」 ✨

day17 code


上一篇
Chapter 3:Vue 魔法生命師法順序 — 觀察、召喚與解除的時機
系列文
需求至上的 Vue 魔法之旅24
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言